Micron Document
🎖️GitЯра🎖️

Commit 1303f624eea444ac8ee5f712b82665de03b82fa9


Parents : e9707ec
Author : Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-27T14:16:31-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-27T19:16:31Z

fix(ble): Fail bonding promptly when polled state returns none (#5982)

Changes
Diff

diff --git a/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepositoryBondTest.kt b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepositoryBondTest.kt
index a0eb3fe4b4..5b0fa17513 100644
--- a/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepositoryBondTest.kt
+++ b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepositoryBondTest.kt
@@ -168,7 +168,7 @@ class AndroidBluetoothRepositoryBondTest {
previousState = BluetoothDevice.BOND_BONDING,
)
- assertEquals("Bonding failed or rejected", failure.await()?.message)
+ assertEquals(BOND_FAILED_OR_REJECTED_MESSAGE, failure.await()?.message)
}
@Test
@@ -254,6 +254,127 @@ class AndroidBluetoothRepositoryBondTest {
assertNull(failure.await(), "bond() should accept the polled BONDED state before timeout")
}
+ @Test
+ fun `bond fails early when bond state returns none without broadcast`() = runTest(UnconfinedTestDispatcher()) {
+ val mac = "AA:BB:CC:DD:EE:0D"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ val deviceShadow =
+ RobolectricBleBonding.primeBond(
+ mac,
+ bondState = BluetoothDevice.BOND_BONDING,
+ createBondReturns = false,
+ )
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ val failure = launchBond(repo, mac)
+ advanceTimeBy(499L)
+ assertFalse(failure.isCompleted, "bond() should still be waiting before the poll interval")
+ deviceShadow.setBondState(BluetoothDevice.BOND_NONE)
+ advanceTimeBy(2L)
+
+ assertEquals(BOND_FAILED_OR_REJECTED_MESSAGE, failure.await()?.message)
+ }
+
+ @Test
+ fun `createBond true does not fail immediately while bond state is initially none`() =
+ runTest(UnconfinedTestDispatcher()) {
+ val mac = "AA:BB:CC:DD:EE:0E"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ val deviceShadow =
+ RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_NONE, createBondReturns = true)
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ val failure = launchBond(repo, mac)
+ // Robolectric synchronously marks createBond() as BONDED here; reset it to model
+ // Android's async BOND_NONE -> BOND_BONDING transition.
+ deviceShadow.setBondState(BluetoothDevice.BOND_NONE)
+ advanceTimeBy(499L)
+ assertFalse(failure.isCompleted, "bond() should still be waiting before the initial grace poll")
+ advanceTimeBy(2L)
+ assertFalse(failure.isCompleted, "a newly initiated bond should tolerate initial BOND_NONE")
+ // The same wait must still recover if Android later reports BONDING and then BONDED.
+ deviceShadow.setBondState(BluetoothDevice.BOND_BONDING)
+ advanceTimeBy(500L)
+ assertFalse(failure.isCompleted, "BOND_BONDING should keep the bond wait active")
+ deviceShadow.setBondState(BluetoothDevice.BOND_BONDED)
+ advanceTimeBy(500L)
+
+ assertNull(failure.await(), "bond() should complete once Android reports BOND_BONDED")
+ }
+
+ @Test
+ fun `createBond true fails on the BOND_NONE poll after grace is exhausted`() = runTest(UnconfinedTestDispatcher()) {
+ val mac = "AA:BB:CC:DD:EE:0F"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ val deviceShadow =
+ RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_NONE, createBondReturns = true)
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ val failure = launchBond(repo, mac)
+ // Robolectric synchronously marks createBond() as BONDED here; reset it to model
+ // Android's async BOND_NONE -> BOND_BONDING transition.
+ deviceShadow.setBondState(BluetoothDevice.BOND_NONE)
+ advanceTimeBy(499L)
+ assertFalse(failure.isCompleted, "bond() should still be waiting before the initial grace poll")
+ advanceTimeBy(2L)
+ assertFalse(failure.isCompleted, "the first BOND_NONE poll should still be within the grace")
+ advanceTimeBy(500L)
+ assertFalse(failure.isCompleted, "the second BOND_NONE poll should consume the grace")
+ advanceTimeBy(500L)
+
+ assertEquals(BOND_FAILED_OR_REJECTED_MESSAGE, failure.await()?.message)
+ }
+
+ @Test
+ fun `createBond true fails when polled bonding later returns none`() = runTest(UnconfinedTestDispatcher()) {
+ val mac = "AA:BB:CC:DD:EE:10"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ val deviceShadow =
+ RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_NONE, createBondReturns = true)
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ val failure = launchBond(repo, mac)
+ // Robolectric synchronously marks createBond() as BONDED here; reset it to model
+ // Android's async BOND_NONE -> BOND_BONDING transition.
+ deviceShadow.setBondState(BluetoothDevice.BOND_NONE)
+ advanceTimeBy(501L)
+ assertFalse(failure.isCompleted, "initial BOND_NONE should not fail before bonding is observed")
+ // Covers the missed failure broadcast after polling observes BOND_BONDING. The createBond=false
+ // BOND_BONDING test pins the already-in-flight start path.
+ deviceShadow.setBondState(BluetoothDevice.BOND_BONDING)
+ advanceTimeBy(500L)
+ assertFalse(failure.isCompleted, "polled BOND_BONDING should keep the bond wait active")
+ deviceShadow.setBondState(BluetoothDevice.BOND_NONE)
+ advanceTimeBy(500L)
+
+ assertEquals(BOND_FAILED_OR_REJECTED_MESSAGE, failure.await()?.message)
+ }
+
+ @Test
+ fun `spurious none broadcast is ignored but later polled none fails`() = runTest(UnconfinedTestDispatcher()) {
+ val mac = "AA:BB:CC:DD:EE:11"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ val deviceShadow =
+ RobolectricBleBonding.primeBond(
+ mac,
+ bondState = BluetoothDevice.BOND_BONDING,
+ createBondReturns = false,
+ )
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ val failure = launchBond(repo, mac)
+ RobolectricBleBonding.sendBondStateChanged(
+ mac,
+ newState = BluetoothDevice.BOND_NONE,
+ previousState = BluetoothDevice.BOND_NONE,
+ )
+ assertFalse(failure.isCompleted, "a spurious BOND_NONE broadcast must not resolve the bond")
+ deviceShadow.setBondState(BluetoothDevice.BOND_NONE)
+ advanceTimeBy(500L)
+
+ assertEquals(BOND_FAILED_OR_REJECTED_MESSAGE, failure.await()?.message)
+ }
+
@Test
fun `bond succeeds when bond state becomes bonded at timeout boundary`() = runTest(UnconfinedTestDispatcher()) {
val mac = "AA:BB:CC:DD:EE:0C"

diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
index 2f7e623fad..f2a576b0c8 100644
--- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
+++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
@@ -36,12 +36,18 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val BOND_TIMEOUT = 30.seconds
private val BOND_STATE_POLL_INTERVAL = 500.milliseconds
+// Fixed two-poll grace for slow BOND_NONE -> BOND_BONDING transitions after createBond() returns true.
+// Tune this or track observed transition latency if a specific OEM needs a longer window.
+private val CREATED_BOND_NONE_GRACE = BOND_STATE_POLL_INTERVAL + BOND_STATE_POLL_INTERVAL
+internal const val BOND_FAILED_OR_REJECTED_MESSAGE = "Bonding failed or rejected"
+
/** Android implementation of [BluetoothRepository]. */
@Single
class AndroidBluetoothRepository(
@@ -103,8 +109,8 @@ class AndroidBluetoothRepository(
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
try {
- startOrObserveBond(remoteDevice, result)
- awaitBondResult(remoteDevice, result)
+ val start = startOrObserveBond(remoteDevice, result)
+ awaitBondResult(remoteDevice, result, start)
} finally {
unregisterBondReceiver(receiver)
}
@@ -122,44 +128,65 @@ class AndroidBluetoothRepository(
@Suppress("TooGenericExceptionCaught")
@SuppressLint("MissingPermission")
- private fun startOrObserveBond(remoteDevice: android.bluetooth.BluetoothDevice, result: CompletableDeferred<Unit>) {
+ private fun startOrObserveBond(
+ remoteDevice: android.bluetooth.BluetoothDevice,
+ result: CompletableDeferred<Unit>,
+ ): BondWaitStart {
+ var start = BondWaitStart()
try {
- if (result.isCompleted) return
-
- if (remoteDevice.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
- result.complete(Unit)
- } else if (!remoteDevice.createBond()) {
- // createBond() returns false when a bond is already in flight, triggered by a GATT
- // operation hitting a secured characteristic, or already established.
- // ACTION_BOND_STATE_CHANGED is unreliable on some devices (see Kable #111), so
- // re-check bondState directly rather than failing the whole flow.
- when (remoteDevice.bondState) {
- android.bluetooth.BluetoothDevice.BOND_BONDED -> {
- result.complete(Unit)
- }
+ if (!result.isCompleted) {
+ if (remoteDevice.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
+ result.complete(Unit)
+ } else if (remoteDevice.createBond()) {
+ start = BondWaitStart(createdBond = true)
+ } else {
+ // createBond() returns false when a bond is already in flight, triggered by a GATT
+ // operation hitting a secured characteristic, or already established.
+ // ACTION_BOND_STATE_CHANGED is unreliable on some devices (see Kable #111), so
+ // re-check bondState directly rather than failing the whole flow.
+ when (remoteDevice.bondState) {
+ android.bluetooth.BluetoothDevice.BOND_BONDED -> {
+ result.complete(Unit)
+ }
- android.bluetooth.BluetoothDevice.BOND_BONDING -> {
- // Bond already in progress; leave the receiver registered to resolve it on
- // the terminal BOND_BONDED / BOND_NONE transition instead of treating this
- // as a failure.
- Logger.d { "createBond() returned false but bonding is already in progress" }
- }
+ android.bluetooth.BluetoothDevice.BOND_BONDING -> {
+ // Bond already in progress; leave the receiver registered to resolve it on
+ // the terminal BOND_BONDED / BOND_NONE transition instead of treating this
+ // as a failure.
+ Logger.d { "createBond() returned false but bonding is already in progress" }
+ start = BondWaitStart(bondingObserved = true)
+ }
- else -> {
- result.completeExceptionally(Exception("Failed to initiate bonding"))
+ else -> {
+ result.completeExceptionally(Exception("Failed to initiate bonding"))
+ }
}
}
}
} catch (e: Exception) {
result.completeExceptionally(e)
}
+ return start
}
+ private data class BondWaitStart(val bondingObserved: Boolean = false, val createdBond: Boolean = false)
+
@SuppressLint("MissingPermission")
private suspend fun awaitBondResult(
remoteDevice: android.bluetooth.BluetoothDevice,
result: CompletableDeferred<Unit>,
+ start: BondWaitStart,
) {
+ var bondingWasInFlight = start.bondingObserved
+ // createBond() can return true before Android reports BOND_BONDING. Tolerate two polled
+ // BOND_NONE samples (polls 1-2), then fail on the third persistent BOND_NONE.
+ var createdBondNoneGraceRemaining =
+ if (start.createdBond) {
+ CREATED_BOND_NONE_GRACE
+ } else {
+ Duration.ZERO
+ }
+
while (!result.isCompleted) {
val completedFromReceiver =
withTimeoutOrNull(BOND_STATE_POLL_INTERVAL) {
@@ -167,8 +194,42 @@ class AndroidBluetoothRepository(
true
} == true
- if (!completedFromReceiver && remoteDevice.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
- result.complete(Unit)
+ if (!completedFromReceiver) {
+ when (remoteDevice.bondState) {
+ android.bluetooth.BluetoothDevice.BOND_BONDED -> {
+ result.complete(Unit)
+ }
+
+ android.bluetooth.BluetoothDevice.BOND_BONDING -> {
+ // Once polling observes BOND_BONDING, a later BOND_NONE is terminal. Keep this
+ // defensive for any path that observes in-flight bonding outside the start state.
+ bondingWasInFlight = true
+ createdBondNoneGraceRemaining = Duration.ZERO
+ }
+
+ android.bluetooth.BluetoothDevice.BOND_NONE -> {
+ // Invariant: if start.createdBond is false, startOrObserveBond either completed
+ // result or observed BOND_BONDING, which is represented by bondingWasInFlight.
+ val pollFailureDetails =
+ "bond poll failed: bondingWasInFlight=$bondingWasInFlight " +
+ "createdBond=${start.createdBond} graceRemaining=$createdBondNoneGraceRemaining"
+ when {
+ bondingWasInFlight -> {
+ Logger.d { pollFailureDetails }
+ result.completeExceptionally(Exception(BOND_FAILED_OR_REJECTED_MESSAGE))
+ }
+
+ createdBondNoneGraceRemaining > Duration.ZERO -> {
+ createdBondNoneGraceRemaining -= BOND_STATE_POLL_INTERVAL
+ }
+
+ start.createdBond -> {
+ Logger.d { pollFailureDetails }
+ result.completeExceptionally(Exception(BOND_FAILED_OR_REJECTED_MESSAGE))
+ }
+ }
+ }
+ }
}
}
result.await()
@@ -206,7 +267,7 @@ class AndroidBluetoothRepository(
state == android.bluetooth.BluetoothDevice.BOND_NONE &&
prevState == android.bluetooth.BluetoothDevice.BOND_BONDING
) {
- result.completeExceptionally(Exception("Bonding failed or rejected"))
+ result.completeExceptionally(Exception(BOND_FAILED_OR_REJECTED_MESSAGE))
}
}
}

Served by rngit 1.5.2 - Generated in 0.16s